﻿// Project: Salient
// http://salient.codeplex.com
// 
// Copyright 2010, Sky Sanders <sky at skysanders.net>
// Dual licensed under the MIT or GPL Version 2 licenses.
// http://salient.codeplex.com/license
// 
// Date: April 25 2010 

#region

using System;
using System.IO;
using System.Text;
using System.Web;
using System.Web.Configuration;
using System.Web.Script.Serialization;
using System.Web.Security;
using Salient.Web.Configuration;

#endregion

namespace Salient.Web.Security
{
    /// <summary>
    /// 
    /// </summary>
    public class AccessControlModule : IHttpModule
    {
        #region IHttpModule Members

        /// <summary>
        /// Initializes a module and prepares it to handle requests.
        /// </summary>
        /// <param name="context">An <see cref="T:System.Web.HttpApplication"/> that provides access to the methods, properties, and events common to all application objects within an ASP.NET application 
        ///                 </param>
        public void Init(HttpApplication context)
        {
            context.BeginRequest += OnBeginRequest;
            context.PostAuthenticateRequest += OnPostAuthenticateRequest;
            context.PreSendRequestHeaders += OnPreSendRequestHeaders;
        }

        /// <summary>
        /// Disposes of the resources (other than memory) used by the module that implements <see cref="T:System.Web.IHttpModule"/>.
        /// </summary>
        public void Dispose()
        {
        }

        #endregion

        ///<summary>
        ///</summary>
        public static bool Enabled
        {
            get
            {
                return SalientSectionGroup.Instance.AccessControl.Enabled;
            }   
        }

        /// <summary>
        /// Read username and password from AccessControlHeader if FormsAuthentication
        /// is enabled and there is no FormsAuthentication ticket cookie.
        /// 
        /// WARNING: Username and Password are transmitted in plain text. Use this mechanism
        /// only over a secure connection.
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private static void OnBeginRequest(object sender, EventArgs e)
        {
            HttpApplication application = (HttpApplication)sender;

            AuthenticationSection auth =
                (AuthenticationSection)WebConfigurationManager.GetWebApplicationSection("system.web/authentication");
            if (auth.Mode == AuthenticationMode.Forms)
            {
                
                AccessControlHeader header =
                    AccessControlHeader.FromJSON(
                        application.Request.Headers[SalientSectionGroup.Instance.AccessControl.HttpHeaderName]);

                if (header.logout)
                {
                    FormsAuthentication.SignOut();
                }

                if (application.Request.Cookies.Get(FormsAuthentication.FormsCookieName) == null || header.logout)
                {
                    
                    MembershipProvider provider = Membership.Provider;

                    if (!string.IsNullOrEmpty(header.username) && !string.IsNullOrEmpty(header.password))
                    {
                        bool valid = provider.ValidateUser(header.username, header.password);
                        if (valid)
                        {
                            FormsAuthentication.SetAuthCookie(header.username, false);
                        }
                    }
                }
            }
        }

        /// <summary>
        /// Checks authorization status for current prinicipal and issues 401/403
        /// if appropriate.
        /// 
        /// If not a script request and request is 403 if a custom error page is defined
        /// it will be served.
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private static void OnPostAuthenticateRequest(object sender, EventArgs e)
        {
            // regardless of header, if module is disabled - return;
            if (!Enabled)
            {
                return;
            }

            HttpApplication application = (HttpApplication) sender;
            

            // check for the request header
            AccessControlHeader header =
                AccessControlHeader.FromJSON(application.Request.Headers[SalientSectionGroup.Instance.AccessControl.HttpHeaderName]);

            if (header.mode == AccessControlMode.disabled)
            {
                return;
            }


            // get calculated permissions for current principal in both authenticated and unauthenticated states
            AccessControlPrincipal acp = new AccessControlPrincipal(application) {Authenticated = true};

            bool authenticatedAccess = UrlAuthorizationModule.CheckUrlAccessForPrincipal(application.Request.Path, acp,
                                                                                         application.Request.RequestType);
            acp.Authenticated = false;
            bool unauthenticatedAccess = UrlAuthorizationModule.CheckUrlAccessForPrincipal(application.Request.Path, acp,
                                                                                           application.Request.
                                                                                               RequestType);


            if (SalientSectionGroup.Instance.AccessControl.SetResponseHeader)
            {
                // create a logging header that can be used by the caller.
                // my intended usage is to cached permissions on the client to prevent unnecessary bounces and 
                // enable background login.
                
                string headerValue = new AccessControlHeader
                    {
                        username = acp.Identity.Name,
                        path = application.Request.Path,
                        rawUrl = application.Request.RawUrl,
                        requestType = application.Request.RequestType,
                        unauthenticated = unauthenticatedAccess,
                        authenticated = authenticatedAccess,
                        loginResult = application.User.Identity.IsAuthenticated
                    }.ToJSON();

                application.Response.AppendHeader(SalientSectionGroup.Instance.AccessControl.HttpHeaderName, headerValue);
                HttpContext.Current.Items["AccessControlModuleHeaderValue"] = headerValue;
            }


            if (!authenticatedAccess &&
                (application.User.Identity.IsAuthenticated || (header.mode == AccessControlMode.script)))
            {
                // tell the rest of the pipeline this is not the request they are looking for
                application.Context.SkipAuthorization = true;

                // if not scriptAccess, check for a 403 custom error page
                if (!(header.mode == AccessControlMode.script) && ProcessCustomError(application.Context, 403))
                {
                    // found and processed a custom error page. 
                    // we are done here.
                    return;
                }

                // write it out as a 403 and let it go. the rest of the pipeline will
                // not touch it.
                WriteException(application.Context, 403, (header.mode == AccessControlMode.script));
                // go to EndRequest
                application.CompleteRequest();
            }
            else if (!unauthenticatedAccess && !application.User.Identity.IsAuthenticated &&
                     (header.mode == AccessControlMode.script))
            {
                // tell the rest of the pipeline this is not the request they are looking for
                application.Context.SkipAuthorization = true;
                // write it out as a 401
                WriteException(application.Context, 401, true);
                // change the status to 499 to sneak past 
                // formsAuthenticationModule.onLeave in PreSendRequestHeaders
                application.Response.StatusCode = 499;
                // go to EndRequest
                application.CompleteRequest();
            }
        }

        /// <summary>
        /// Check for 499 and change it back to 401 now that we are past
        /// FormsAuthenticationModule
        /// </summary>
        private static void OnPreSendRequestHeaders(object sender, EventArgs e)
        {
            HttpApplication application = (HttpApplication) sender;

            if (application.Response.StatusCode == 499)
            {
                // we sneaked a 401 past formsAuthenticationModule.
                application.Response.StatusCode = 401;
            }
        }


        /// <summary>
        /// Aftermarket custom error processor. 
        /// Created to enable custom error pages for 403
        /// but should work fine for any status code.
        /// 
        /// OT: why did MS include 403 in the custom error template example
        /// if it can never be called? Am I the only person who has noticed this?
        /// </summary>
        /// <param name="context"></param>
        /// <param name="statusCode"></param>
        /// <returns></returns>
        public static bool ProcessCustomError(HttpContext context, int statusCode)
        {
            try
            {
                CustomErrorsSection customErrors =
                    (CustomErrorsSection) WebConfigurationManager.GetWebApplicationSection("system.web/customErrors");

                if (customErrors == null)
                {
                    return false;
                }

                if ((customErrors.Mode == CustomErrorsMode.RemoteOnly && context.Request.Url.IsLoopback) ||
                    customErrors.Mode == CustomErrorsMode.Off)
                {
                    return false;
                }

                CustomError customError = customErrors.Errors.Get(statusCode.ToString());

                if (customError == null)
                {
                    return false;
                }

                string redirectUrl = customError.Redirect;

                if (customErrors.RedirectMode == CustomErrorsRedirectMode.ResponseRewrite)
                {
                    context.Server.Execute(redirectUrl);
                }
                else
                {
                    string path = context.Request.RawUrl;

                    if (path.IndexOf('?') < 0)
                    {
                        // do some basic url encoding.
                        if ((path.IndexOf(' ') >= 0))
                        {
                            path = path.Replace(" ", "%20");
                        }
                        redirectUrl = redirectUrl + "?aspxerrorpath=" + path;
                    }
                    context.Response.Redirect(redirectUrl, false);
                }

                return true;
            }
// ReSharper disable EmptyGeneralCatchClause
            catch
// ReSharper restore EmptyGeneralCatchClause
            {
            }
            return false;
        }


        private static void WriteException(HttpContext context, int statusCode, bool json)
        {
            // clear the response
            string charset = context.Response.Charset;
            context.Response.ClearHeaders();
            context.Response.ClearContent();
            context.Response.Clear();

            // prepare the error
            context.Response.StatusCode = statusCode;
            context.Response.StatusDescription = HttpWorkerRequest.GetStatusDescription(statusCode);

            // prepare a message. if JSON request shape the message like an exception.
            string message = json
                                 ? (new JavaScriptSerializer()).Serialize(
                                       new
                                           {
                                               message = context.Response.Status,
                                               status = statusCode,
                                               statusText = context.Response.StatusDescription
                                           })
                                 : context.Response.Status;
            context.Response.ContentType = json ? "application/json" : "text/plain";
            if (json)
            {
                context.Response.AddHeader("jsonerror", "true");
            }
            // replace our logging header, if present

            string headerValue = (string) HttpContext.Current.Items["AccessControlModuleHeaderValue"];
            if (!string.IsNullOrEmpty(headerValue))
            {
                context.Response.AppendHeader(SalientSectionGroup.Instance.AccessControl.HttpHeaderName, headerValue);
            }

            // helps prevent farking IE 1203x errors when using ports/ssl. Not entirely but some.
            context.Response.AddHeader("connection", "close");

            context.Response.Charset = charset;
            context.Response.TrySkipIisCustomErrors = true;

            using (StreamWriter writer = new StreamWriter(context.Response.OutputStream, new UTF8Encoding(false)))
            {
                writer.Write(message);
                writer.Flush();
            }
        }
    }
}